/*
 * ExtensionsClassLoader.java 2 sept. 2007
 *
 * Sweet Home 3D, Copyright (c) 2007-2008 Emmanuel PUYBARET / eTeks <info@eteks.com>
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
 */
package com.eteks.sweethome3d.tools;

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLConnection;
import java.security.ProtectionDomain;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;

/**
 * Class loader able to load classes and DLLs with a higher priority from a given set of JARs. 
 * Its bytecode is Java 1.1 compatible to be loadable by old JVMs.
 * @author Emmanuel Puybaret
 */
public class ExtensionsClassLoader extends ClassLoader
{
	private final ProtectionDomain protectionDomain;
	private final String[] applicationPackages;
	
	private final Map extensionDlls = new HashMap();
	private JarFile[] extensionJars = null;
	
	/**
	 * Creates a class loader. It will consider JARs and DLLs of <code>extensionJarsAndDlls</code> accessed as resources
	 * as classpath and libclasspath elements with a higher priority than the ones of default classpath, 
	 * and will load itself all the classes belonging to packages of <code>applicationPackages</code>.
	 * No cache will be used.
	 */
	public ExtensionsClassLoader(ClassLoader parent, ProtectionDomain protectionDomain, String[] extensionJarsAndDlls,
			String[] applicationPackages)
	{
		this(parent, protectionDomain, extensionJarsAndDlls, new URL[0], applicationPackages, null, null);
	}
	
	/**
	 * Creates a class loader. It will consider JARs and DLLs of <code>extensionJarAndDllResources</code>
	 * and <code>extensionJarAndDllUrls</code> as classpath and libclasspath elements with a higher priority 
	 * than the ones of default classpath, and will load itself all the classes belonging to packages of 
	 * <code>applicationPackages</code>.<br>
	 * Copies of <code>extensionJarAndDllResources</code> and <code>extensionJarAndDllUrls</code> will be stored 
	 * in the given cache folder if possible, each file being prefixed by <code>cachedFilesPrefix</code>.
	 */
	public ExtensionsClassLoader(ClassLoader parent, ProtectionDomain protectionDomain,
			String[] extensionJarAndDllResources, URL[] extensionJarAndDllUrls, String[] applicationPackages,
			File cacheFolder, String cachedFilesPrefix)
	{
		this(parent, protectionDomain, extensionJarAndDllResources, extensionJarAndDllUrls, applicationPackages,
				cacheFolder, cachedFilesPrefix, false);
	}
	
	/**
	 * Creates a class loader. It will consider JARs and DLLs of <code>extensionJarAndDllResources</code>
	 * and <code>extensionJarAndDllUrls</code> as classpath and libclasspath elements with a higher priority 
	 * than the ones of default classpath, and will load itself all the classes belonging to packages of 
	 * <code>applicationPackages</code>.<br>
	 * Copies of <code>extensionJarAndDllResources</code> and <code>extensionJarAndDllUrls</code> will be stored 
	 * in the given cache folder if possible, each file being prefixed by <code>cachedFilesPrefix</code>.
	 */
	public ExtensionsClassLoader(ClassLoader parent, ProtectionDomain protectionDomain,
			String[] extensionJarAndDllResources, URL[] extensionJarAndDllUrls, String[] applicationPackages,
			File cacheFolder, String cachedFilesPrefix, boolean cacheOnlyJars)
	{
		super(parent);
		this.protectionDomain = protectionDomain;
		this.applicationPackages = applicationPackages;
		String extensionPrefix = cachedFilesPrefix == null ? "" : cachedFilesPrefix;
		
		// Compute DLLs prefix and suffix
		String dllSuffix;
		String dllPrefix;
		
		String osName = System.getProperty("os.name");
		if (osName.startsWith("Windows"))
		{
			dllSuffix = ".dll";
			dllPrefix = "";
		}
		else if (osName.startsWith("Mac OS X"))
		{
			dllSuffix = ".jnilib";
			dllPrefix = "lib";
		}
		else
		{
			dllSuffix = ".so";
			dllPrefix = "lib";
		}
		
		// Create a list containing only URLs
		ArrayList extensionJarsAndDlls = new ArrayList();
		for (int i = 0; i < extensionJarAndDllResources.length; i++)
		{
			URL extensionJarOrDllUrl = getResource(extensionJarAndDllResources[i]);
			if (extensionJarOrDllUrl != null)
			{
				extensionJarsAndDlls.add(extensionJarOrDllUrl);
			}
		}
		if (extensionJarAndDllUrls != null)
		{
			extensionJarsAndDlls.addAll(Arrays.asList(extensionJarAndDllUrls));
		}
		
		// Find extension Jars and DLLs
		ArrayList extensionJars = new ArrayList();
		for (int i = 0; i < extensionJarsAndDlls.size(); i++)
		{
			URL extensionJarOrDllUrl = (URL) extensionJarsAndDlls.get(i);
			try
			{
				String extensionJarOrDllUrlFile = extensionJarOrDllUrl.getFile();
				URLConnection connection = null;
				long extensionJarOrDllFileDate;
				int extensionJarOrDllFileLength;
				String extensionJarOrDllFile;
				if (extensionJarOrDllUrl.getProtocol().equals("jar"))
				{
					// Don't instantiate connection to a file accessed by jar protocol otherwise it might download again its jar container
					URL jarEntryUrl = new URL(
							extensionJarOrDllUrlFile.substring(0, extensionJarOrDllUrlFile.indexOf('!')));
					URLConnection jarEntryUrlConnection = jarEntryUrl.openConnection();
					// connection.getLastModified() on an entry returns get modification date of the jar file itself 
					extensionJarOrDllFileDate = jarEntryUrlConnection.getLastModified();
					extensionJarOrDllFileLength = jarEntryUrlConnection.getContentLength();
					extensionJarOrDllFile = extensionJarOrDllUrlFile
							.substring(extensionJarOrDllUrlFile.indexOf('!') + 2);
				}
				else
				{
					connection = extensionJarOrDllUrl.openConnection();
					extensionJarOrDllFileDate = connection.getLastModified();
					extensionJarOrDllFileLength = connection.getContentLength();
					extensionJarOrDllFile = extensionJarOrDllUrlFile;
				}
				int lastSlashIndex = extensionJarOrDllFile.lastIndexOf('/');
				String libraryName;
				boolean extensionJarFile = extensionJarOrDllFile.endsWith(".jar");
				if (extensionJarFile)
				{
					libraryName = null;
				}
				else if (extensionJarOrDllFile.endsWith(dllSuffix))
				{
					libraryName = extensionJarOrDllFile.substring(lastSlashIndex + 1 + dllPrefix.length(),
							extensionJarOrDllFile.length() - dllSuffix.length());
				}
				else
				{
					// Ignore DLLs of other platforms
					continue;
				}
				
				if (cacheFolder != null && (!cacheOnlyJars || extensionJarFile) && extensionJarOrDllFileDate != 0
						&& extensionJarOrDllFileLength != -1
						&& ((cacheFolder.exists() && cacheFolder.isDirectory()) || cacheFolder.mkdirs()))
				{
					try
					{
						String extensionJarOrDllFileName = extensionPrefix + extensionJarOrDllFileLength + "-"
								+ (extensionJarOrDllFileDate / 1000L) + "-"
								+ extensionJarOrDllFile.substring(lastSlashIndex + 1);
						File cachedFile = new File(cacheFolder, extensionJarOrDllFileName);
						if (!cachedFile.exists() || cachedFile.lastModified() < extensionJarOrDllFileDate)
						{
							// Copy jar to cache
							if (connection == null)
							{
								connection = extensionJarOrDllUrl.openConnection();
							}
							copyInputStreamToFile(connection.getInputStream(), cachedFile);
						}
						if (extensionJarFile)
						{
							// Add tmp file to extension jars list
							extensionJars.add(new JarFile(cachedFile.toString(), false));
						}
						else if (extensionJarOrDllFile.endsWith(dllSuffix))
						{
							// Add tmp file to extension DLLs map
							this.extensionDlls.put(libraryName, cachedFile.toString());
						}
						continue;
					}
					catch (IOException ex)
					{
						// Try without cache
					}
				}
				
				if (connection == null)
				{
					connection = extensionJarOrDllUrl.openConnection();
				}
				InputStream input = connection.getInputStream();
				if (extensionJarFile)
				{
					// Copy jar to a tmp file
					String extensionJar = copyInputStreamToTmpFile(input, ".jar");
					// Add tmp file to extension jars list
					extensionJars.add(new JarFile(extensionJar, false));
				}
				else if (extensionJarOrDllFile.endsWith(dllSuffix))
				{
					// Copy DLL to a tmp file
					String extensionDll = copyInputStreamToTmpFile(input, dllSuffix);
					// Add tmp file to extension DLLs map
					this.extensionDlls.put(libraryName, extensionDll);
				}
			}
			catch (IOException ex)
			{
				throw new RuntimeException("Couldn't extract extension " + extensionJarOrDllUrl, ex);
			}
		}
		
		// Create extensionJars array
		if (extensionJars.size() > 0)
		{
			this.extensionJars = (JarFile[]) extensionJars.toArray(new JarFile[extensionJars.size()]);
		}
	}
	
	/**
	 * Returns the file name of a temporary copy of <code>input</code> content.
	 */
	private String copyInputStreamToTmpFile(InputStream input, String suffix) throws IOException
	{
		File tmpFile = File.createTempFile("extension", suffix);
		tmpFile.deleteOnExit();
		copyInputStreamToFile(input, tmpFile);
		return tmpFile.toString();
	}
	
	/**
	 * Copies the <code>input</code> content to the given file.
	 */
	public void copyInputStreamToFile(InputStream input, File file) throws FileNotFoundException, IOException
	{
		OutputStream output = null;
		try
		{
			output = new BufferedOutputStream(new FileOutputStream(file));
			byte[] buffer = new byte[8192];
			int size;
			while ((size = input.read(buffer)) != -1)
			{
				output.write(buffer, 0, size);
			}
		}
		finally
		{
			if (input != null)
			{
				input.close();
			}
			if (output != null)
			{
				output.close();
			}
		}
	}
	
	/**
	 * Finds and defines the given class among the extension JARs  
	 * given in constructor, then among resources. 
	 */
	protected Class findClass(String name) throws ClassNotFoundException
	{
		// Build class file from its name 
		String classFile = name.replace('.', '/') + ".class";
		InputStream classInputStream = null;
		if (this.extensionJars != null)
		{
			// Check if searched class is an extension class
			for (int i = 0; i < this.extensionJars.length; i++)
			{
				JarFile extensionJar = this.extensionJars[i];
				JarEntry jarEntry = extensionJar.getJarEntry(classFile);
				if (jarEntry != null)
				{
					try
					{
						classInputStream = extensionJar.getInputStream(jarEntry);
					}
					catch (IOException ex)
					{
						throw new ClassNotFoundException("Couldn't read class " + name, ex);
					}
				}
			}
		}
		// If it's not an extension class, search if its an application 
		// class that can be read from resources
		if (classInputStream == null)
		{
			URL url = getResource(classFile);
			if (url == null)
			{
				throw new ClassNotFoundException("Class " + name);
			}
			try
			{
				classInputStream = url.openStream();
			}
			catch (IOException ex)
			{
				throw new ClassNotFoundException("Couldn't read class " + name, ex);
			}
		}
		
		try
		{
			// Read class input content to a byte array
			ByteArrayOutputStream out = new ByteArrayOutputStream();
			BufferedInputStream in = new BufferedInputStream(classInputStream);
			byte[] buffer = new byte[8192];
			int size;
			while ((size = in.read(buffer)) != -1)
			{
				out.write(buffer, 0, size);
			}
			in.close();
			// Define class
			return defineClass(name, out.toByteArray(), 0, out.size(), this.protectionDomain);
		}
		catch (IOException ex)
		{
			throw new ClassNotFoundException("Class " + name, ex);
		}
	}
	
	/**
	 * Returns the library path of an extension DLL.
	 */
	protected String findLibrary(String libname)
	{
		return (String) this.extensionDlls.get(libname);
	}
	
	/**
	 * Returns the URL of the given resource searching first if it exists among 
	 * the extension JARs given in constructor. 
	 */
	protected URL findResource(String name)
	{
		if (this.extensionJars != null)
		{
			// Try to find if resource belongs to one of the extracted jars
			for (int i = 0; i < this.extensionJars.length; i++)
			{
				JarFile extensionJar = this.extensionJars[i];
				JarEntry jarEntry = extensionJar.getJarEntry(name);
				if (jarEntry != null)
				{
					try
					{
						return new URL("jar:file:" + extensionJar.getName() + "!/" + jarEntry.getName());
					}
					catch (MalformedURLException ex)
					{
						// Forget that we could have found a resource
					}
				}
			}
		}
		return super.findResource(name);
	}
	
	/**
	 * Loads a class with this class loader if its package belongs to <code>applicationPackages</code>
	 * given in constructor.
	 */
	protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException
	{
		// If no extension jars couldn't be found
		if (this.extensionJars == null)
		{
			// Let default class loader do its job
			return super.loadClass(name, resolve);
		}
		// Check if the class has already been loaded
		Class loadedClass = findLoadedClass(name);
		if (loadedClass == null)
		{
			try
			{
				// Try to find if class belongs to one of the application packages
				for (int i = 0; i < this.applicationPackages.length; i++)
				{
					String applicationPackage = this.applicationPackages[i];
					int applicationPackageLength = applicationPackage.length();
					if ((applicationPackageLength == 0 && name.indexOf('.') == 0)
							|| (applicationPackageLength > 0 && name.startsWith(applicationPackage)))
					{
						loadedClass = findClass(name);
						break;
					}
				}
			}
			catch (ClassNotFoundException ex)
			{
				// Let a chance to class to be loaded by default implementation
			}
			if (loadedClass == null)
			{
				loadedClass = super.loadClass(name, resolve);
			}
		}
		if (resolve)
		{
			resolveClass(loadedClass);
		}
		return loadedClass;
	}
}
